feat(react): add createPortal support#2501
Conversation
Public API: `createPortal(children, container)` where `container` is a
ref obtained from a `<view ref={...}/>`. Wraps preact/compat's
createPortal and resolves the ref to its backing
BackgroundSnapshotInstance.
Internal layout chosen to avoid `delay.ts` taking an import dependency
on `backgroundSnapshot.ts` (which would close a cycle via
`backgroundSnapshot.ts → snapshot/ref.ts → delay.ts`):
- `delay.ts` exposes `refProxyRefAttr`, a WeakMap that only stores the
`[snapshotInstanceId, expIndex]` tuple for each minted RefProxy.
- `snapshot/refProxyBackgroundSnapshotInstance.ts` (new) composes that
WeakMap with `backgroundSnapshotInstanceManager` to produce the
`.get(ref)?.()` resolver consumed by `createPortal`.
The test file runs the useRef+useEffect variant today; the idiomatic
`ref={setState}` variant is kept as a parallel `describe.skip` block
and will be un-skipped once the ref-apply dedup fix lands here from
the companion PR.
🦋 Changeset detectedLatest commit: e0f4a9f The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Merging this PR will improve performance by 8.86%
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ⚡ | 002-hello-reactLynx-destroyBackground |
893.7 µs | 821 µs | +8.86% |
Comparing feat/support-portal (e0f4a9f) with main (b1809ef)
Footnotes
-
26 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩
Web Explorer#9242 Bundle Size — 900.02KiB (0%).e0f4a9f(current) vs b1809ef main#9240(baseline) Bundle metrics
Bundle size by type
|
| Current #9242 |
Baseline #9240 |
|
|---|---|---|
495.88KiB |
495.88KiB |
|
401.92KiB |
401.92KiB |
|
2.22KiB |
2.22KiB |
Bundle analysis report Branch feat/support-portal Project dashboard
Generated by RelativeCI Documentation Report issue
React Example#7669 Bundle Size — 225.43KiB (+0.02%).e0f4a9f(current) vs b1809ef main#7667(baseline) Bundle metrics
Bundle size by type
Bundle analysis report Branch feat/support-portal Project dashboard Generated by RelativeCI Documentation Report issue |
React MTF Example#801 Bundle Size — 196.59KiB (+0.03%).e0f4a9f(current) vs b1809ef main#799(baseline) Bundle metrics
Bundle size by type
Bundle analysis report Branch feat/support-portal Project dashboard Generated by RelativeCI Documentation Report issue |
React External#785 Bundle Size — 684.64KiB (+0.64%).e0f4a9f(current) vs b1809ef main#783(baseline) Bundle metrics
Bundle size by type
Bundle analysis report Branch feat/support-portal Project dashboard Generated by RelativeCI Documentation Report issue |
- Introduce `swc_plugin_portal_container` pre-pass that expands a truthy
`portal-container` attribute into `{<elem>{null}</elem>}`, forcing the
host to be emitted as its own snapshot with a single empty slot at
element_index 0 — exactly the shape `createPortal` validates.
- `createPortal` now short-circuits null/undefined containers, and
rejects refs whose backing snapshot doesn't carry the empty slot,
pointing users at `portal-container` in the error message.
- Add the `'portal-container'?: boolean` prop in `@lynx-js/types`.
- Wire `createPortal` into the `lazy/` re-exports so the lazy-exports
parity test stays green.
- Tests: new rust unit + composed tests for the pre-pass, runtime unit
tests covering all createPortal branches, and a testing-library
negative test plus an element-tree bubble check.
Allow framework-internal `__root` (imported from `@lynx-js/react/internal`) to be passed directly to `createPortal`, rendering children under the page root without requiring a `portal-container`-marked host element.
fa64fa9 to
b2b9776
Compare
This reverts commit b2b9776.
This reverts commit c04aea3.
- fireEvent.tap/longtap now default to bubbles: true, matching the
Lynx runtime where bind/catch listeners fire in the bubble phase
- Skip read-only Event accessors (bubbles/cancelable/composed) in
Object.assign so the EventInit dict isn't reassigned (strict-mode
TypeError on Event.prototype getters)
- Cover bind/catch/capture-bind/capture-catch propagation in
events.test.jsx
- Enable portal tests for the ref={setState} pattern
… to patch Portal support is implemented as a small runtime adjustment without adding a new public API surface, so a patch bump is sufficient.
The useRef + useEffect dance is a workaround; the idiomatic ref-as-callback form already works.
Adds a fixture-level describe block exercising the portal-container
plugin through the full transformReactLynx pipeline: standalone
snapshot shape, separate-snapshot extraction when nested, the
children-not-allowed error, and the ={false} short-circuit. The
plugin's own crate tests live in Rust; these pin the JS-side output.
Per the Lynx TouchEvent spec only TouchEvent-family events have capture/bubble phases, so extend the bubbles: true default from tap/longtap to touchstart/touchmove/touchend/touchcancel. Other LynxEvent entries (bgload, transitionend, layoutchange, …) stay non-bubbling — that matches Lynx where they have no symmetric capture-bind/capture-catch API.
In @lynx-js/types every event whose handler signature is
EventHandler<BaseTouchEvent<T>> is part of the TouchEvent family —
that includes longpress alongside tap/longtap/touch{start,move,end,
cancel}. Make the fireEvent default bubbling reflect that, and extend
the parametrized bubble test to cover longpress.
createPortal's signature was tightened to require a NodesRef; the 'returns null when container is null or undefined' case is gone (the matching test in @lynx-js/react testing-library was already removed when the API was changed). Remove the runtime-side counterpart and sync the JSDoc.
# Conflicts: # packages/react/transform/src/lib.rs # packages/react/transform/swc-plugin-reactlynx/src/lib.rs
The dedicated refProxyBackgroundSnapshotInstance.ts wrapper added an extra getter indirection and a hydrationMap remap that createPortal doesn't need — the call site already runs after mount, where backgroundSnapshotInstanceManager is keyed by the same id stored in refProxyRefAttr. Drop the helper file and look up the bsi directly in portals.ts.
Renders a vnode subtree into a different ReactLynx element identified by
a `NodesRef` (from `ref={setX}` or `lynx.createSelectorQuery()`), without
requiring any compile-time marker attribute. Implementation routes portal
ops through the existing SnapshotInstance/patch abstraction:
- New `nodesRefInsertBefore` / `nodesRefRemoveChild` patch ops; carried
via the regular `LifecycleConstant.patchUpdate` channel alongside BSI
CreateElement / InsertBefore / RemoveChild ops.
- `fakeRoot.insertBefore` wires `child.__parent = fakeRoot` so preact's
`removeNode` (which walks `child.parentNode.removeChild`) routes through
portal removeChild, otherwise unmount silently no-ops.
- Pre-hydrate Portal mounts queue into `pendingInsertBefore`;
`clearPendingPortalInsertBefore` (called from hydrate) replays the BSI
subtree's dropped CreateElement / SetAttributes / internal InsertBefore
ops via `reconstructInstanceTree`, then attaches the subtree to host
via `nodesRefInsertBefore`.
- `reconstructInstanceTree` extracted to its own module so portal's
pre-hydrate replay can share the helper without forming an import
cycle with `backgroundSnapshot.ts`.
Different design from #2501 (which uses a `portal-container` SWC
transform to lift the host subtree into a separate snapshot) — this one
stays inside the existing SnapshotInstance/patch model so hydrate diff
and future first-screen-direct-render paths can be reused without
protocol changes.
Tests cover pre-/post-hydrate mount, unmount via `componentWillUnmount`,
container swap, multi-child reorder + prepend, context propagation
across portal boundary, ctx-not-found soft-fail on apply, and host
selector miss; runtime test env gets `__GetPageElement` /
`__QuerySelector` mocks.
Renders a vnode subtree into a different ReactLynx element identified by
a `NodesRef` (from `ref={setX}` or `lynx.createSelectorQuery()`), without
requiring any compile-time marker attribute. Implementation routes portal
ops through the existing SnapshotInstance/patch abstraction:
- New `nodesRefInsertBefore` / `nodesRefRemoveChild` patch ops; carried
via the regular `LifecycleConstant.patchUpdate` channel alongside BSI
CreateElement / InsertBefore / RemoveChild ops.
- `fakeRoot.insertBefore` wires `child.__parent = fakeRoot` so preact's
`removeNode` (which walks `child.parentNode.removeChild`) routes through
portal removeChild, otherwise unmount silently no-ops.
- Pre-hydrate Portal mounts queue into `pendingInsertBefore`;
`clearPendingPortalInsertBefore` (called from hydrate) replays the BSI
subtree's dropped CreateElement / SetAttributes / internal InsertBefore
ops via `reconstructInstanceTree`, then attaches the subtree to host
via `nodesRefInsertBefore`.
- `reconstructInstanceTree` extracted to its own module so portal's
pre-hydrate replay can share the helper without forming an import
cycle with `backgroundSnapshot.ts`.
Different design from #2501 (which uses a `portal-container` SWC
transform to lift the host subtree into a separate snapshot) — this one
stays inside the existing SnapshotInstance/patch model so hydrate diff
and future first-screen-direct-render paths can be reused without
protocol changes.
Tests cover pre-/post-hydrate mount, unmount via `componentWillUnmount`,
container swap, multi-child reorder + prepend, context propagation
across portal boundary, ctx-not-found soft-fail on apply, and host
selector miss; runtime test env gets `__GetPageElement` /
`__QuerySelector` mocks.
Renders a vnode subtree into a different ReactLynx element identified by
a `NodesRef` (from `ref={setX}` or `lynx.createSelectorQuery()`), without
requiring any compile-time marker attribute. Implementation routes portal
ops through the existing SnapshotInstance/patch abstraction:
- New `nodesRefInsertBefore` / `nodesRefRemoveChild` patch ops; carried
via the regular `LifecycleConstant.patchUpdate` channel alongside BSI
CreateElement / InsertBefore / RemoveChild ops.
- `fakeRoot.insertBefore` wires `child.__parent = fakeRoot` so preact's
`removeNode` (which walks `child.parentNode.removeChild`) routes through
portal removeChild, otherwise unmount silently no-ops.
- Pre-hydrate Portal mounts queue into `pendingInsertBefore`;
`clearPendingPortalInsertBefore` (called from hydrate) replays the BSI
subtree's dropped CreateElement / SetAttributes / internal InsertBefore
ops via `reconstructInstanceTree`, then attaches the subtree to host
via `nodesRefInsertBefore`.
- `reconstructInstanceTree` extracted to its own module so portal's
pre-hydrate replay can share the helper without forming an import
cycle with `backgroundSnapshot.ts`.
Different design from #2501 (which uses a `portal-container` SWC
transform to lift the host subtree into a separate snapshot) — this one
stays inside the existing SnapshotInstance/patch model so hydrate diff
and future first-screen-direct-render paths can be reused without
protocol changes.
Tests cover pre-/post-hydrate mount, unmount via `componentWillUnmount`,
container swap, multi-child reorder + prepend, context propagation
across portal boundary, ctx-not-found soft-fail on apply, and host
selector miss; runtime test env gets `__GetPageElement` /
`__QuerySelector` mocks.
Renders a vnode subtree into a different ReactLynx element identified by
a `NodesRef` (from `ref={setX}` or `lynx.createSelectorQuery()`), without
requiring any compile-time marker attribute. Implementation routes portal
ops through the existing SnapshotInstance/patch abstraction:
- New `nodesRefInsertBefore` / `nodesRefRemoveChild` patch ops; carried
via the regular `LifecycleConstant.patchUpdate` channel alongside BSI
CreateElement / InsertBefore / RemoveChild ops.
- `fakeRoot.insertBefore` wires `child.__parent = fakeRoot` so preact's
`removeNode` (which walks `child.parentNode.removeChild`) routes through
portal removeChild, otherwise unmount silently no-ops.
- Pre-hydrate Portal mounts queue into `pendingInsertBefore`;
`clearPendingPortalInsertBefore` (called from hydrate) replays the BSI
subtree's dropped CreateElement / SetAttributes / internal InsertBefore
ops via `reconstructInstanceTree`, then attaches the subtree to host
via `nodesRefInsertBefore`.
- `reconstructInstanceTree` extracted to its own module so portal's
pre-hydrate replay can share the helper without forming an import
cycle with `backgroundSnapshot.ts`.
Different design from #2501 (which uses a `portal-container` SWC
transform to lift the host subtree into a separate snapshot) — this one
stays inside the existing SnapshotInstance/patch model so hydrate diff
and future first-screen-direct-render paths can be reused without
protocol changes.
Tests cover pre-/post-hydrate mount, unmount via `componentWillUnmount`,
container swap, multi-child reorder + prepend, context propagation
across portal boundary, ctx-not-found soft-fail on apply, and host
selector miss; runtime test env gets `__GetPageElement` /
`__QuerySelector` mocks.
Renders a vnode subtree into a different ReactLynx element identified by
a `NodesRef` (from `ref={setX}` or `lynx.createSelectorQuery()`), without
requiring any compile-time marker attribute. Implementation routes portal
ops through the existing SnapshotInstance/patch abstraction:
- New `nodesRefInsertBefore` / `nodesRefRemoveChild` patch ops; carried
via the regular `LifecycleConstant.patchUpdate` channel alongside BSI
CreateElement / InsertBefore / RemoveChild ops.
- `fakeRoot.insertBefore` wires `child.__parent = fakeRoot` so preact's
`removeNode` (which walks `child.parentNode.removeChild`) routes through
portal removeChild, otherwise unmount silently no-ops.
- Pre-hydrate Portal mounts queue into `pendingInsertBefore`;
`clearPendingPortalInsertBefore` (called from hydrate) replays the BSI
subtree's dropped CreateElement / SetAttributes / internal InsertBefore
ops via `reconstructInstanceTree`, then attaches the subtree to host
via `nodesRefInsertBefore`.
- `reconstructInstanceTree` extracted to its own module so portal's
pre-hydrate replay can share the helper without forming an import
cycle with `backgroundSnapshot.ts`.
Different design from #2501 (which uses a `portal-container` SWC
transform to lift the host subtree into a separate snapshot) — this one
stays inside the existing SnapshotInstance/patch model so hydrate diff
and future first-screen-direct-render paths can be reused without
protocol changes.
Tests cover pre-/post-hydrate mount, unmount via `componentWillUnmount`,
container swap, multi-child reorder + prepend, context propagation
across portal boundary, ctx-not-found soft-fail on apply, and host
selector miss; runtime test env gets `__GetPageElement` /
`__QuerySelector` mocks. testing-library suite includes a preact-parity
case ported from internal-preact's `feat/portal-slot` branch verifying
that portal content stays put while host's normal children toggle.
Renders a vnode subtree into a different ReactLynx element identified by
a `NodesRef` (from `ref={setX}` or `lynx.createSelectorQuery()`), without
requiring any compile-time marker attribute. Implementation routes portal
ops through the existing SnapshotInstance/patch abstraction:
- New `nodesRefInsertBefore` / `nodesRefRemoveChild` patch ops; carried
via the regular `LifecycleConstant.patchUpdate` channel alongside BSI
CreateElement / InsertBefore / RemoveChild ops.
- `fakeRoot.insertBefore` wires `child.__parent = fakeRoot` so preact's
`removeNode` (which walks `child.parentNode.removeChild`) routes through
portal removeChild, otherwise unmount silently no-ops.
- Pre-hydrate Portal mounts queue into `pendingInsertBefore`;
`clearPendingPortalInsertBefore` (called from hydrate) replays the BSI
subtree's dropped CreateElement / SetAttributes / internal InsertBefore
ops via `reconstructInstanceTree`, then attaches the subtree to host
via `nodesRefInsertBefore`.
- `reconstructInstanceTree` extracted to its own module so portal's
pre-hydrate replay can share the helper without forming an import
cycle with `backgroundSnapshot.ts`.
Different design from #2501 (which uses a `portal-container` SWC
transform to lift the host subtree into a separate snapshot) — this one
stays inside the existing SnapshotInstance/patch model so hydrate diff
and future first-screen-direct-render paths can be reused without
protocol changes.
Tests cover pre-/post-hydrate mount, unmount via `componentWillUnmount`,
container swap, multi-child reorder + prepend, context propagation
across portal boundary, ctx-not-found soft-fail on apply, and host
selector miss; runtime test env gets `__GetPageElement` /
`__QuerySelector` mocks. testing-library suite includes a preact-parity
case ported from internal-preact's `feat/portal-slot` branch verifying
that portal content stays put while host's normal children toggle.
Renders a vnode subtree into a different ReactLynx element identified by
a `NodesRef` (from `ref={setX}` or `lynx.createSelectorQuery()`), without
requiring any compile-time marker attribute. Implementation routes portal
ops through the existing SnapshotInstance/patch abstraction:
- New `nodesRefInsertBefore` / `nodesRefRemoveChild` patch ops; carried
via the regular `LifecycleConstant.patchUpdate` channel alongside BSI
CreateElement / InsertBefore / RemoveChild ops.
- `fakeRoot.insertBefore` wires `child.__parent = fakeRoot` so preact's
`removeNode` (which walks `child.parentNode.removeChild`) routes through
portal removeChild, otherwise unmount silently no-ops.
- Pre-hydrate Portal mounts queue into `pendingInsertBefore`;
`clearPendingPortalInsertBefore` (called from hydrate) replays the BSI
subtree's dropped CreateElement / SetAttributes / internal InsertBefore
ops via `reconstructInstanceTree`, then attaches the subtree to host
via `nodesRefInsertBefore`.
- `reconstructInstanceTree` extracted to its own module so portal's
pre-hydrate replay can share the helper without forming an import
cycle with `backgroundSnapshot.ts`.
Different design from #2501 (which uses a `portal-container` SWC
transform to lift the host subtree into a separate snapshot) — this one
stays inside the existing SnapshotInstance/patch model so hydrate diff
and future first-screen-direct-render paths can be reused without
protocol changes.
Tests cover pre-/post-hydrate mount, unmount via `componentWillUnmount`,
container swap, multi-child reorder + prepend, context propagation
across portal boundary, ctx-not-found soft-fail on apply, and host
selector miss; runtime test env gets `__GetPageElement` /
`__QuerySelector` mocks. testing-library suite includes a preact-parity
case ported from internal-preact's `feat/portal-slot` branch verifying
that portal content stays put while host's normal children toggle.
Renders a vnode subtree into a different ReactLynx element identified by
a `NodesRef` (from `ref={setX}` or `lynx.createSelectorQuery()`), without
requiring any compile-time marker attribute. Implementation routes portal
ops through the existing SnapshotInstance/patch abstraction:
- New `nodesRefInsertBefore` / `nodesRefRemoveChild` patch ops; carried
via the regular `LifecycleConstant.patchUpdate` channel alongside BSI
CreateElement / InsertBefore / RemoveChild ops.
- `fakeRoot.insertBefore` wires `child.__parent = fakeRoot` so preact's
`removeNode` (which walks `child.parentNode.removeChild`) routes through
portal removeChild, otherwise unmount silently no-ops.
- Pre-hydrate Portal mounts queue into `pendingInsertBefore`;
`clearPendingPortalInsertBefore` (called from hydrate) replays the BSI
subtree's dropped CreateElement / SetAttributes / internal InsertBefore
ops via `reconstructInstanceTree`, then attaches the subtree to host
via `nodesRefInsertBefore`.
- `reconstructInstanceTree` extracted to its own module so portal's
pre-hydrate replay can share the helper without forming an import
cycle with `backgroundSnapshot.ts`.
Different design from #2501 (which uses a `portal-container` SWC
transform to lift the host subtree into a separate snapshot) — this one
stays inside the existing SnapshotInstance/patch model so hydrate diff
and future first-screen-direct-render paths can be reused without
protocol changes.
Tests cover pre-/post-hydrate mount, unmount via `componentWillUnmount`,
container swap, multi-child reorder + prepend, context propagation
across portal boundary, ctx-not-found soft-fail on apply, and host
selector miss; runtime test env gets `__GetPageElement` /
`__QuerySelector` mocks. testing-library suite includes a preact-parity
case ported from internal-preact's `feat/portal-slot` branch verifying
that portal content stays put while host's normal children toggle.
Renders a vnode subtree into a different ReactLynx element identified by
a `NodesRef` (from `ref={setX}` or `lynx.createSelectorQuery()`), without
requiring any compile-time marker attribute. Implementation routes portal
ops through the existing SnapshotInstance/patch abstraction:
- New `nodesRefInsertBefore` / `nodesRefRemoveChild` patch ops; carried
via the regular `LifecycleConstant.patchUpdate` channel alongside BSI
CreateElement / InsertBefore / RemoveChild ops.
- `fakeRoot.insertBefore` wires `child.__parent = fakeRoot` so preact's
`removeNode` (which walks `child.parentNode.removeChild`) routes through
portal removeChild, otherwise unmount silently no-ops.
- Pre-hydrate Portal mounts queue into `pendingInsertBefore`;
`clearPendingPortalInsertBefore` (called from hydrate) replays the BSI
subtree's dropped CreateElement / SetAttributes / internal InsertBefore
ops via `reconstructInstanceTree`, then attaches the subtree to host
via `nodesRefInsertBefore`.
- `reconstructInstanceTree` extracted to its own module so portal's
pre-hydrate replay can share the helper without forming an import
cycle with `backgroundSnapshot.ts`.
Different design from #2501 (which uses a `portal-container` SWC
transform to lift the host subtree into a separate snapshot) — this one
stays inside the existing SnapshotInstance/patch model so hydrate diff
and future first-screen-direct-render paths can be reused without
protocol changes.
Tests cover pre-/post-hydrate mount, unmount via `componentWillUnmount`,
container swap, multi-child reorder + prepend, context propagation
across portal boundary, ctx-not-found soft-fail on apply, and host
selector miss; runtime test env gets `__GetPageElement` /
`__QuerySelector` mocks. testing-library suite includes a preact-parity
case ported from internal-preact's `feat/portal-slot` branch verifying
that portal content stays put while host's normal children toggle.
Renders a vnode subtree into a different ReactLynx element identified by
a `NodesRef` (from `ref={setX}` or `lynx.createSelectorQuery()`), without
requiring any compile-time marker attribute. Implementation routes portal
ops through the existing SnapshotInstance/patch abstraction:
- New `nodesRefInsertBefore` / `nodesRefRemoveChild` patch ops; carried
via the regular `LifecycleConstant.patchUpdate` channel alongside BSI
CreateElement / InsertBefore / RemoveChild ops.
- `fakeRoot.insertBefore` wires `child.__parent = fakeRoot` so preact's
`removeNode` (which walks `child.parentNode.removeChild`) routes through
portal removeChild, otherwise unmount silently no-ops.
- Pre-hydrate Portal mounts queue into `pendingInsertBefore`;
`clearPendingPortalInsertBefore` (called from hydrate) replays the BSI
subtree's dropped CreateElement / SetAttributes / internal InsertBefore
ops via `reconstructInstanceTree`, then attaches the subtree to host
via `nodesRefInsertBefore`.
- `reconstructInstanceTree` extracted to its own module so portal's
pre-hydrate replay can share the helper without forming an import
cycle with `backgroundSnapshot.ts`.
Different design from #2501 (which uses a `portal-container` SWC
transform to lift the host subtree into a separate snapshot) — this one
stays inside the existing SnapshotInstance/patch model so hydrate diff
and future first-screen-direct-render paths can be reused without
protocol changes.
Tests cover pre-/post-hydrate mount, unmount via `componentWillUnmount`,
container swap, multi-child reorder + prepend, context propagation
across portal boundary, ctx-not-found soft-fail on apply, and host
selector miss; runtime test env gets `__GetPageElement` /
`__QuerySelector` mocks. testing-library suite includes a preact-parity
case ported from internal-preact's `feat/portal-slot` branch verifying
that portal content stays put while host's normal children toggle.
## Summary
Adds `createPortal` to `@lynx-js/react`. Renders a vnode subtree into a
different ReactLynx element identified by a `NodesRef` (from
`ref={setX}` or `lynx.createSelectorQuery()`), with no compile-time
marker required and arbitrary host structure permitted.
```tsx
function App() {
const [host, setHost] = useState(null);
return (
<view>
<view ref={setHost} />
{host && createPortal(<text>hi</text>, host)}
</view>
);
}
```
## Different design vs #2501
This is an **alternative implementation** to
#2501; both target the
same feature with substantially different architectures. Brief
comparison:
| | #2501 | this PR |
|---|---|---|
| Marker on host | `portal-container` attr required | none required |
| Compile-time work | SWC plugin lifts host subtree into a separate
snapshot | no transform — pure runtime |
| Host can have children | no (must be empty placeholder) | yes |
| Patch protocol | new "detached subtree" lifecycle ops | two new ops on
the existing `LifecycleConstant.patchUpdate` channel |
| Hydrate diff path | bypassed (portal subtree is detached) | reused —
portal subtree replays through `reconstructInstanceTree` |
| First-screen direct render | needs separate machinery |
future-compatible: portal ops sit in the same Snapshot abstraction the
direct-render path already consumes |
Concretely, this implementation routes everything through
`SnapshotInstance` so no new lifecycle protocol is introduced; the
trade-off is that pre-hydrate Portal mounts need an extra
queue-and-replay step (`pendingInsertBefore` →
`clearPendingPortalInsertBefore`) instead of being lifted by a
transform.
## Limitation
Currently `NodesRef` is a BTS type, we cannot get it on MTS. So IFR is
not supported.
## Implementation notes
- New patch ops: **`nodesRefInsertBefore(identifier, childId,
beforeId?)`** / **`nodesRefRemoveChild(identifier, childId)`**. Carried
via the existing `patchUpdate` channel alongside BSI `CreateElement` /
`InsertBefore` / `RemoveChild` ops; no new protocol.
- `fakeRoot.insertBefore` wires `child.__parent = fakeRoot` so preact's
`removeNode` (which walks `child.parentNode.removeChild(child)`) routes
through portal `removeChild`. Without this, unmount silently no-ops.
- Pre-hydrate Portal mounts: BSI constructor's `CreateElement` push is
dropped (global buffer is `undefined`), so `fakeRoot.insertBefore`
queues into `pendingInsertBefore`. `clearPendingPortalInsertBefore`
(called from `hydrate()`) replays the dropped subtree ops via
`reconstructInstanceTree([child])`, then emits `nodesRefInsertBefore` to
attach to host.
- `reconstructInstanceTree` extracted to its own module
(`snapshot/reconstructInstanceTree.ts`) so portal pre-hydrate replay can
share the helper without forming an import cycle with
`backgroundSnapshot.ts`.
## Test coverage
Two test suites:
**`runtime/__test__/snapshot/lynx/portals.test.jsx`** — unit tests on
the runtime path (10 cases):
- `createPortal` returns a VNode whose `containerInfo` points at the
host
- pre-hydrate → hydrate → unmount full lifecycle
- post-hydrate Portal mount via state change
- container swap (covers `_this._container !== container` path)
- multi-child reorder + prepend (covers `before?.__id` truthy branch +
apply `__InsertElementBefore`)
- context propagation across portal boundary (`ContextProvider` wrapper)
- `serializeNodesRef` non-RefProxy path
- `nodesRefInsertBefore` / `nodesRefRemoveChild` ctx-not-found soft-fail
- selector miss no-op
**`testing-library/src/__tests__/portals.test.jsx`** — high-level
scenarios mirroring PR #2501's portal.test.jsx (16 cases): basic render,
re-render on portalled state change, context forwarding, event-bubbling
semantics across portal boundary, third-party-slot pattern,
mount/unmount toggle, cleanup on unmount, host swap.
Runtime test env (`__test__/snapshot/utils/nativeMethod.ts`) gets
`__GetPageElement` + `__QuerySelector` mocks (`[attr]` / `[attr=value]`
selectors).
Coverage: **100% lines / 100% branches / 100% functions / 100%
statements** on the runtime suite.
## Test plan
- [x] `pnpm -F @lynx-js/react-runtime test` — 570 passing, 100% coverage
- [x] `pnpm -F @lynx-js/reactlynx-testing-library test:base --
src/__tests__/portals.test.jsx` — 16 passing
- [ ] hand-review by owners on which design (#2501 or this) better fits
the long-term path
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Added createPortal() to render subtrees into alternate element targets
via node-reference selectors.
* **Tests**
* Expanded portal test coverage: hydration, mount/hydrate/unmount flows,
container swapping, insertion ordering, context propagation,
selector-targeted portals, and teardown/regressions.
* **Improvements**
* Improved portal hydration replay and snapshot patch handling; enhanced
node-reference resolution and testing-environment query helpers.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Note
Depends on #2500 (ref-apply dedup) for the idiomatic
ref={setState}pattern; the skipped test block flips todescribeonce #2500 lands.Summary
createPortal(children, container)from@lynx-js/react.containeris a ref to a ReactLynx element marked with the newportal-containerattribute.portal-container— SWC pre-pass (swc_plugin_portal_container) emits a snapshot with a single empty slot at element_index 0, which is exactly whatcreatePortalvalidates.Usage
portal-containerelement must have no children (compile error otherwise).={false}/ dynamic exprs are no-ops.lynx.createSelectorQuery()or third-party sources throw.Notes
delay.tsstays cycle-free by keeping onlyrefProxyRefAttr: WeakMap<object, [siId, expIndex]>;snapshot/refProxyBackgroundSnapshotInstance.tscomposes it with the snapshot manager.Test plan
pnpm --filter @lynx-js/react-runtime test(coverage 100% onportals.ts)pnpm --filter @lynx-js/react-testing-library test— block (A) 11/11 greencargo test -p swc_plugin_portal_container -p swc_plugin_snapshotpnpm run api-extractordescribe.skipflipped